import {
  AdditiveBlending,
  Color,
  DoubleSide,
  LinearFilter,
  Matrix4,
  MeshBasicMaterial,
  MeshDepthMaterial,
  NoBlending,
  RGBADepthPacking,
  RGBAFormat,
  ShaderMaterial,
  UniformsUtils,
  Vector2,
  Vector3,
  WebGLRenderTarget
} from 'three'
import { Pass, FullScreenQuad } from 'three/examples/jsm/postprocessing/Pass.js'
import { CopyShader } from 'three/examples/jsm/shaders/CopyShader.js'

class OutlinePass extends Pass {
  constructor (resolution, scene, camera, selectedObjects) {
    super()

    this.renderScene = scene
    this.renderCamera = camera
    this.selectedObjects = selectedObjects !== undefined ? selectedObjects : []
    this.visibleEdgeColor = new Color(1, 1, 1)
    this.hiddenEdgeColor = new Color(0.1, 0.04, 0.02)
    this.edgeGlow = 0.0
    this.usePatternTexture = false
    this.edgeThickness = 1.0
    this.edgeStrength = 3.0
    this.downSampleRatio = 2
    this.pulsePeriod = 0

    this._visibilityCache = new Map()

    this.resolution = (resolution !== undefined) ? new Vector2(resolution.x, resolution.y) : new Vector2(256, 256)

    const pars = { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat }

    const resx = Math.round(this.resolution.x / this.downSampleRatio)
    const resy = Math.round(this.resolution.y / this.downSampleRatio)

    this.maskBufferMaterial = new MeshBasicMaterial({ color: 0xffffff })
    this.maskBufferMaterial.side = DoubleSide
    this.renderTargetMaskBuffer = new WebGLRenderTarget(this.resolution.x, this.resolution.y, pars)
    this.renderTargetMaskBuffer.texture.name = 'OutlinePass.mask'
    this.renderTargetMaskBuffer.texture.generateMipmaps = false

    this.depthMaterial = new MeshDepthMaterial()
    this.depthMaterial.side = DoubleSide
    this.depthMaterial.depthPacking = RGBADepthPacking
    this.depthMaterial.blending = NoBlending

    this.prepareMaskMaterial = this.getPrepareMaskMaterial()
    this.prepareMaskMaterial.side = DoubleSide
    this.prepareMaskMaterial.fragmentShader = replaceDepthToViewZ(this.prepareMaskMaterial.fragmentShader, this.renderCamera)

    this.renderTargetDepthBuffer = new WebGLRenderTarget(this.resolution.x, this.resolution.y, pars)
    this.renderTargetDepthBuffer.texture.name = 'OutlinePass.depth'
    this.renderTargetDepthBuffer.texture.generateMipmaps = false

    this.renderTargetMaskDownSampleBuffer = new WebGLRenderTarget(resx, resy, pars)
    this.renderTargetMaskDownSampleBuffer.texture.name = 'OutlinePass.depthDownSample'
    this.renderTargetMaskDownSampleBuffer.texture.generateMipmaps = false

    this.renderTargetBlurBuffer1 = new WebGLRenderTarget(resx, resy, pars)
    this.renderTargetBlurBuffer1.texture.name = 'OutlinePass.blur1'
    this.renderTargetBlurBuffer1.texture.generateMipmaps = false
    this.renderTargetBlurBuffer2 = new WebGLRenderTarget(Math.round(resx / 2), Math.round(resy / 2), pars)
    this.renderTargetBlurBuffer2.texture.name = 'OutlinePass.blur2'
    this.renderTargetBlurBuffer2.texture.generateMipmaps = false

    this.edgeDetectionMaterial = this.getEdgeDetectionMaterial()
    this.renderTargetEdgeBuffer1 = new WebGLRenderTarget(resx, resy, pars)
    this.renderTargetEdgeBuffer1.texture.name = 'OutlinePass.edge1'
    this.renderTargetEdgeBuffer1.texture.generateMipmaps = false
    this.renderTargetEdgeBuffer2 = new WebGLRenderTarget(Math.round(resx / 2), Math.round(resy / 2), pars)
    this.renderTargetEdgeBuffer2.texture.name = 'OutlinePass.edge2'
    this.renderTargetEdgeBuffer2.texture.generateMipmaps = false

    const MAX_EDGE_THICKNESS = 4
    const MAX_EDGE_GLOW = 4

    this.separableBlurMaterial1 = this.getSeperableBlurMaterial(MAX_EDGE_THICKNESS)
    this.separableBlurMaterial1.uniforms.texSize.value.set(resx, resy)
    this.separableBlurMaterial1.uniforms.kernelRadius.value = 1
    this.separableBlurMaterial2 = this.getSeperableBlurMaterial(MAX_EDGE_GLOW)
    this.separableBlurMaterial2.uniforms.texSize.value.set(Math.round(resx / 2), Math.round(resy / 2))
    this.separableBlurMaterial2.uniforms.kernelRadius.value = MAX_EDGE_GLOW

    // Overlay material
    this.overlayMaterial = this.getOverlayMaterial()

    // copy material
    if (CopyShader === undefined) console.error('THREE.OutlinePass relies on CopyShader')

    const copyShader = CopyShader

    this.copyUniforms = UniformsUtils.clone(copyShader.uniforms)
    this.copyUniforms.opacity.value = 1.0

    this.materialCopy = new ShaderMaterial({
      uniforms: this.copyUniforms,
      vertexShader: copyShader.vertexShader,
      fragmentShader: copyShader.fragmentShader,
      blending: NoBlending,
      depthTest: false,
      depthWrite: false,
      transparent: true
    })

    this.enabled = true
    this.needsSwap = false

    this._oldClearColor = new Color()
    this.oldClearAlpha = 1

    this.fsQuad = new FullScreenQuad(null)

    this.tempPulseColor1 = new Color()
    this.tempPulseColor2 = new Color()
    this.textureMatrix = new Matrix4()

    function replaceDepthToViewZ (string, camera) {
      var type = camera.isPerspectiveCamera ? 'perspective' : 'orthographic'

      return string.replace(/DEPTH_TO_VIEW_Z/g, type + 'DepthToViewZ')
    }
  }

  dispose () {
    this.renderTargetMaskBuffer.dispose()
    this.renderTargetDepthBuffer.dispose()
    this.renderTargetMaskDownSampleBuffer.dispose()
    this.renderTargetBlurBuffer1.dispose()
    this.renderTargetBlurBuffer2.dispose()
    this.renderTargetEdgeBuffer1.dispose()
    this.renderTargetEdgeBuffer2.dispose()
  }

  setSize (width, height) {
    this.renderTargetMaskBuffer.setSize(width, height)
    this.renderTargetDepthBuffer.setSize(width, height)

    let resx = Math.round(width / this.downSampleRatio)
    let resy = Math.round(height / this.downSampleRatio)
    this.renderTargetMaskDownSampleBuffer.setSize(resx, resy)
    this.renderTargetBlurBuffer1.setSize(resx, resy)
    this.renderTargetEdgeBuffer1.setSize(resx, resy)
    this.separableBlurMaterial1.uniforms.texSize.value.set(resx, resy)

    resx = Math.round(resx / 2)
    resy = Math.round(resy / 2)

    this.renderTargetBlurBuffer2.setSize(resx, resy)
    this.renderTargetEdgeBuffer2.setSize(resx, resy)

    this.separableBlurMaterial2.uniforms.texSize.value.set(resx, resy)
  }

  changeVisibilityOfSelectedObjects (bVisible) {
    const cache = this._visibilityCache

    function gatherSelectedMeshesCallBack (object) {
      if (object.isMesh) {
        if (bVisible === true) {
          object.visible = cache.get(object)
        } else {
          cache.set(object, object.visible)
          object.visible = bVisible
        }
      }
    }

    for (let i = 0; i < this.selectedObjects.length; i++) {
      const selectedObject = this.selectedObjects[i]
      selectedObject.traverse(gatherSelectedMeshesCallBack)
    }
  }

  changeVisibilityOfNonSelectedObjects (bVisible) {
    const cache = this._visibilityCache
    const selectedMeshes = []

    function gatherSelectedMeshesCallBack (object) {
      if (object.isMesh) selectedMeshes.push(object)
    }

    for (let i = 0; i < this.selectedObjects.length; i++) {
      const selectedObject = this.selectedObjects[i]
      selectedObject.traverse(gatherSelectedMeshesCallBack)
    }

    function VisibilityChangeCallBack (object) {
      if (object.isMesh || object.isSprite) {
        // only meshes and sprites are supported by OutlinePass

        let bFound = false

        for (let i = 0; i < selectedMeshes.length; i++) {
          const selectedObjectId = selectedMeshes[i].id

          if (selectedObjectId === object.id) {
            bFound = true
            break
          }
        }

        if (bFound === false) {
          const visibility = object.visible

          if (bVisible === false || cache.get(object) === true) {
            object.visible = bVisible
          }

          cache.set(object, visibility)
        }
      } else if (object.isPoints || object.isLine || object.type === 'Object3D') {
        // the visibilty of points and lines is always set to false in order to
        // not affect the outline computation

        if (bVisible === true) {
          object.visible = cache.get(object) // restore
        } else {
          cache.set(object, object.visible)
          object.visible = bVisible
        }
      }
    }

    this.renderScene.traverse(VisibilityChangeCallBack)
  }

  updateTextureMatrix () {
    this.textureMatrix.set(0.5, 0.0, 0.0, 0.5,
      0.0, 0.5, 0.0, 0.5,
      0.0, 0.0, 0.5, 0.5,
      0.0, 0.0, 0.0, 1.0)
    this.textureMatrix.multiply(this.renderCamera.projectionMatrix)
    this.textureMatrix.multiply(this.renderCamera.matrixWorldInverse)
  }

  render (renderer, writeBuffer, readBuffer, deltaTime, maskActive) {
    if (this.selectedObjects.length > 0) {
      renderer.getClearColor(this._oldClearColor)
      this.oldClearAlpha = renderer.getClearAlpha()
      const oldAutoClear = renderer.autoClear

      renderer.autoClear = false

      if (maskActive) renderer.state.buffers.stencil.setTest(false)

      renderer.setClearColor(0xffffff, 1)

      // Make selected objects invisible
      this.changeVisibilityOfSelectedObjects(false)

      const currentBackground = this.renderScene.background
      this.renderScene.background = null

      // 1. Draw Non Selected objects in the depth buffer
      this.renderScene.overrideMaterial = this.depthMaterial
      renderer.setRenderTarget(this.renderTargetDepthBuffer)
      renderer.clear()
      renderer.render(this.renderScene, this.renderCamera)

      // Make selected objects visible
      this.changeVisibilityOfSelectedObjects(true)
      this._visibilityCache.clear()

      // Update Texture Matrix for Depth compare
      this.updateTextureMatrix()

      // Make non selected objects invisible, and draw only the selected objects, by comparing the depth buffer of non selected objects
      this.changeVisibilityOfNonSelectedObjects(false)
      this.renderScene.overrideMaterial = this.prepareMaskMaterial
      this.prepareMaskMaterial.uniforms.cameraNearFar.value.set(this.renderCamera.near, this.renderCamera.far)
      this.prepareMaskMaterial.uniforms.depthTexture.value = this.renderTargetDepthBuffer.texture
      this.prepareMaskMaterial.uniforms.textureMatrix.value = this.textureMatrix
      renderer.setRenderTarget(this.renderTargetMaskBuffer)
      renderer.clear()
      renderer.render(this.renderScene, this.renderCamera)
      this.renderScene.overrideMaterial = null
      this.changeVisibilityOfNonSelectedObjects(true)
      this._visibilityCache.clear()

      this.renderScene.background = currentBackground

      // 2. Downsample to Half resolution
      this.fsQuad.material = this.materialCopy
      this.copyUniforms.tDiffuse.value = this.renderTargetMaskBuffer.texture
      renderer.setRenderTarget(this.renderTargetMaskDownSampleBuffer)
      renderer.clear()
      this.fsQuad.render(renderer)

      this.tempPulseColor1.copy(this.visibleEdgeColor)
      this.tempPulseColor2.copy(this.hiddenEdgeColor)

      if (this.pulsePeriod > 0) {
        const scalar = (1 + 0.25) / 2 + Math.cos(performance.now() * 0.01 / this.pulsePeriod) * (1.0 - 0.25) / 2
        this.tempPulseColor1.multiplyScalar(scalar)
        this.tempPulseColor2.multiplyScalar(scalar)
      }

      // 3. Apply Edge Detection Pass
      this.fsQuad.material = this.edgeDetectionMaterial
      this.edgeDetectionMaterial.uniforms.maskTexture.value = this.renderTargetMaskDownSampleBuffer.texture
      this.edgeDetectionMaterial.uniforms.texSize.value.set(this.renderTargetMaskDownSampleBuffer.width, this.renderTargetMaskDownSampleBuffer.height)
      this.edgeDetectionMaterial.uniforms.visibleEdgeColor.value = this.tempPulseColor1
      this.edgeDetectionMaterial.uniforms.hiddenEdgeColor.value = this.tempPulseColor2
      renderer.setRenderTarget(this.renderTargetEdgeBuffer1)
      renderer.clear()
      this.fsQuad.render(renderer)

      // 4. Apply Blur on Half res
      this.fsQuad.material = this.separableBlurMaterial1
      this.separableBlurMaterial1.uniforms.colorTexture.value = this.renderTargetEdgeBuffer1.texture
      this.separableBlurMaterial1.uniforms.direction.value = OutlinePass.BlurDirectionX
      this.separableBlurMaterial1.uniforms.kernelRadius.value = this.edgeThickness
      renderer.setRenderTarget(this.renderTargetBlurBuffer1)
      renderer.clear()
      this.fsQuad.render(renderer)
      this.separableBlurMaterial1.uniforms.colorTexture.value = this.renderTargetBlurBuffer1.texture
      this.separableBlurMaterial1.uniforms.direction.value = OutlinePass.BlurDirectionY
      renderer.setRenderTarget(this.renderTargetEdgeBuffer1)
      renderer.clear()
      this.fsQuad.render(renderer)

      // Apply Blur on quarter res
      this.fsQuad.material = this.separableBlurMaterial2
      this.separableBlurMaterial2.uniforms.colorTexture.value = this.renderTargetEdgeBuffer1.texture
      this.separableBlurMaterial2.uniforms.direction.value = OutlinePass.BlurDirectionX
      renderer.setRenderTarget(this.renderTargetBlurBuffer2)
      renderer.clear()
      this.fsQuad.render(renderer)
      this.separableBlurMaterial2.uniforms.colorTexture.value = this.renderTargetBlurBuffer2.texture
      this.separableBlurMaterial2.uniforms.direction.value = OutlinePass.BlurDirectionY
      renderer.setRenderTarget(this.renderTargetEdgeBuffer2)
      renderer.clear()
      this.fsQuad.render(renderer)

      // Blend it additively over the input texture
      this.fsQuad.material = this.overlayMaterial
      this.overlayMaterial.uniforms.maskTexture.value = this.renderTargetMaskBuffer.texture
      this.overlayMaterial.uniforms.edgeTexture1.value = this.renderTargetEdgeBuffer1.texture
      this.overlayMaterial.uniforms.edgeTexture2.value = this.renderTargetEdgeBuffer2.texture
      this.overlayMaterial.uniforms.patternTexture.value = this.patternTexture
      this.overlayMaterial.uniforms.edgeStrength.value = this.edgeStrength
      this.overlayMaterial.uniforms.edgeGlow.value = this.edgeGlow
      this.overlayMaterial.uniforms.usePatternTexture.value = this.usePatternTexture

      if (maskActive) renderer.state.buffers.stencil.setTest(true)

      renderer.setRenderTarget(readBuffer)
      this.fsQuad.render(renderer)

      renderer.setClearColor(this._oldClearColor, this.oldClearAlpha)
      renderer.autoClear = oldAutoClear
    }

    if (this.renderToScreen) {
      this.fsQuad.material = this.materialCopy
      this.copyUniforms.tDiffuse.value = readBuffer.texture
      renderer.setRenderTarget(null)
      this.fsQuad.render(renderer)
    }
  }

  getPrepareMaskMaterial () {
    return new ShaderMaterial({

      uniforms: {
        depthTexture: { value: null },
        cameraNearFar: { value: new Vector2(0.5, 0.5) },
        textureMatrix: { value: null }
      },

      vertexShader:
				`#include <morphtarget_pars_vertex>
				#include <skinning_pars_vertex>

				varying vec4 projTexCoord;
				varying vec4 vPosition;
				uniform mat4 textureMatrix;

				void main() {

					#include <skinbase_vertex>
					#include <begin_vertex>
					#include <morphtarget_vertex>
					#include <skinning_vertex>
					#include <project_vertex>

					vPosition = mvPosition;
					vec4 worldPosition = modelMatrix * vec4( transformed, 1.0 );
					projTexCoord = textureMatrix * worldPosition;

				}`,

      fragmentShader:
				`#include <packing>
				varying vec4 vPosition;
				varying vec4 projTexCoord;
				uniform sampler2D depthTexture;
				uniform vec2 cameraNearFar;

				void main() {

					float depth = unpackRGBAToDepth(texture2DProj( depthTexture, projTexCoord ));
					float viewZ = - DEPTH_TO_VIEW_Z( depth, cameraNearFar.x, cameraNearFar.y );
					float depthTest = (-vPosition.z > viewZ) ? 1.0 : 0.0;
					gl_FragColor = vec4(0.0, depthTest, 1.0, 1.0);

				}`

    })
  }

  getEdgeDetectionMaterial () {
    return new ShaderMaterial({

      uniforms: {
        maskTexture: { value: null },
        texSize: { value: new Vector2(0.5, 0.5) },
        visibleEdgeColor: { value: new Vector3(1.0, 1.0, 1.0) },
        hiddenEdgeColor: { value: new Vector3(1.0, 1.0, 1.0) }
      },

      vertexShader:
				`varying vec2 vUv;

				void main() {
					vUv = uv;
					gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
				}`,

      fragmentShader:
				`varying vec2 vUv;

				uniform sampler2D maskTexture;
				uniform vec2 texSize;
				uniform vec3 visibleEdgeColor;
				uniform vec3 hiddenEdgeColor;

				void main() {
					vec2 invSize = 1.0 / texSize;
					vec4 uvOffset = vec4(1.0, 0.0, 0.0, 1.0) * vec4(invSize, invSize);
					vec4 c1 = texture2D( maskTexture, vUv + uvOffset.xy);
					vec4 c2 = texture2D( maskTexture, vUv - uvOffset.xy);
					vec4 c3 = texture2D( maskTexture, vUv + uvOffset.yw);
					vec4 c4 = texture2D( maskTexture, vUv - uvOffset.yw);
					float diff1 = (c1.r - c2.r)*0.5;
					float diff2 = (c3.r - c4.r)*0.5;
					float d = length( vec2(diff1, diff2) );
					float a1 = min(c1.g, c2.g);
					float a2 = min(c3.g, c4.g);
					float visibilityFactor = min(a1, a2);
					vec3 edgeColor = 1.0 - visibilityFactor > 0.001 ? visibleEdgeColor : hiddenEdgeColor;
					gl_FragColor = vec4(edgeColor, 1.0) * vec4(d);
				}`
    })
  }

  getSeperableBlurMaterial (maxRadius) {
    return new ShaderMaterial({

      defines: {
        MAX_RADIUS: maxRadius
      },

      uniforms: {
        colorTexture: { value: null },
        texSize: { value: new Vector2(0.5, 0.5) },
        direction: { value: new Vector2(0.5, 0.5) },
        kernelRadius: { value: 1.0 }
      },

      vertexShader:
				`varying vec2 vUv;

				void main() {
					vUv = uv;
					gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
				}`,

      fragmentShader:
				`#include <common>
				varying vec2 vUv;
				uniform sampler2D colorTexture;
				uniform vec2 texSize;
				uniform vec2 direction;
				uniform float kernelRadius;

				float gaussianPdf(in float x, in float sigma) {
					return 0.39894 * exp( -0.5 * x * x/( sigma * sigma))/sigma;
				}

				void main() {
					vec2 invSize = 1.0 / texSize;
					float weightSum = gaussianPdf(0.0, kernelRadius);
					vec4 diffuseSum = texture2D( colorTexture, vUv) * weightSum;
					vec2 delta = direction * invSize * kernelRadius/float(MAX_RADIUS);
					vec2 uvOffset = delta;
					for( int i = 1; i <= MAX_RADIUS; i ++ ) {
						float w = gaussianPdf(uvOffset.x, kernelRadius);
						vec4 sample1 = texture2D( colorTexture, vUv + uvOffset);
						vec4 sample2 = texture2D( colorTexture, vUv - uvOffset);
						diffuseSum += ((sample1 + sample2) * w);
						weightSum += (2.0 * w);
						uvOffset += delta;
					}
					gl_FragColor = diffuseSum/weightSum;
				}`
    })
  }

  getOverlayMaterial () {
    return new ShaderMaterial({

      uniforms: {
        maskTexture: { value: null },
        edgeTexture1: { value: null },
        edgeTexture2: { value: null },
        patternTexture: { value: null },
        edgeStrength: { value: 1.0 },
        edgeGlow: { value: 1.0 },
        usePatternTexture: { value: 0.0 }
      },

      vertexShader:
				`varying vec2 vUv;

				void main() {
					vUv = uv;
					gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
				}`,

      fragmentShader:
				`varying vec2 vUv;

				uniform sampler2D maskTexture;
				uniform sampler2D edgeTexture1;
				uniform sampler2D edgeTexture2;
				uniform sampler2D patternTexture;
				uniform float edgeStrength;
				uniform float edgeGlow;
				uniform bool usePatternTexture;

				void main() {
					vec4 edgeValue1 = texture2D(edgeTexture1, vUv);
					vec4 edgeValue2 = texture2D(edgeTexture2, vUv);
					vec4 maskColor = texture2D(maskTexture, vUv);
					vec4 patternColor = texture2D(patternTexture, 6.0 * vUv);
					float visibilityFactor = 1.0 - maskColor.g > 0.0 ? 1.0 : 0.5;
					vec4 edgeValue = edgeValue1 + edgeValue2 * edgeGlow;
					vec4 finalColor = edgeStrength * maskColor.r * edgeValue;
					if(usePatternTexture)
						finalColor += + visibilityFactor * (1.0 - maskColor.r) * (1.0 - patternColor.r);
					gl_FragColor = finalColor;
				}`,
      blending: AdditiveBlending,
      depthTest: false,
      depthWrite: false,
      transparent: true
    })
  }
}

OutlinePass.BlurDirectionX = new Vector2(1.0, 0.0)
OutlinePass.BlurDirectionY = new Vector2(0.0, 1.0)

export { OutlinePass }
